Skip to content

Python: Add observability for MCP tool according to latest OTel 1.40.0 conventions#4698

Open
Copilot wants to merge 7 commits intomainfrom
copilot/add-observability-for-mcp-tool
Open

Python: Add observability for MCP tool according to latest OTel 1.40.0 conventions#4698
Copilot wants to merge 7 commits intomainfrom
copilot/add-observability-for-mcp-tool

Conversation

Copy link
Contributor

Copilot AI commented Mar 13, 2026

Motivation and Context

MCP tool calls lacked OpenTelemetry instrumentation, making them invisible in distributed traces. This adds CLIENT spans for tools/call and prompts/get operations following the OTel 1.40.0 MCP semantic conventions.

Description

observability.py

  • Added MCP-specific OtelAttr entries: MCP_METHOD_NAME, MCP_PROTOCOL_VERSION, MCP_SESSION_ID, JSONRPC_REQUEST_ID, JSONRPC_PROTOCOL_VERSION, PROMPT_NAME
  • Added get_mcp_call_span(): creates a SpanKind.CLIENT span named {mcp.method.name} {target} (e.g. tools/call get-weather). Uses "unknown" as the explicit fallback when mcp.method.name is not provided.

_mcp.py

  • Stores _mcp_protocol_version from session.initialize() result for use in spans
  • call_tool() now wraps the MCP call in a CLIENT span with required/recommended attributes (mcp.method.name, gen_ai.tool.name, gen_ai.operation.name, jsonrpc.protocol.version, jsonrpc.request.id, mcp.protocol.version); sets error.type and ERROR status on failures including McpError, ClosedResourceError, tool error results, and ToolExecutionException raised by a custom parse_tool_results (a _span_error_set flag prevents overwriting the "ToolError" attribute already set by the result.isError path)
  • get_prompt() instrumented the same way with prompts/get / gen_ai.prompt.name
  • The JSON-RPC protocol version is derived from mcp.types.JSONRPCRequest's type annotation (jsonrpc: Literal["2.0"]) at module load time rather than hardcoded, ensuring it always matches what the MCP library actually uses
  • Because call_tool now owns a span, _inject_otel_into_mcp_meta() always fires inside an active span context

mcp_github_pat_azure_chat.py

  • Fixed AsyncClient resource leak: the client is now created with async with AsyncClient(...) as http_client combined into the same async with statement as MCPStreamableHTTPTool, ensuring it is always properly closed

mcp/README.md

  • Removed OPENAI_API_KEY / OPENAI_RESPONSES_MODEL_ID from the top-level prerequisites section (those only apply to the OpenAI Responses sample). Replaced with a note directing users to the per-sample prerequisite sections.

Example trace structure for an agent tool call:

execute_tool get-weather          ← FunctionTool span (INTERNAL)
  └─ tools/call get-weather       ← new MCP CLIENT span
       mcp.method.name=tools/call
       gen_ai.tool.name=get-weather
       gen_ai.operation.name=execute_tool
       jsonrpc.protocol.version=2.0
       jsonrpc.request.id=3
       mcp.protocol.version=2025-06-18

Note: If the MCP tool is invoked on the server side, the span won't be created.

Contribution Checklist

  • The code builds clean without any errors or warnings
  • The PR follows the Contribution Guidelines
  • All unit tests pass, and I have added new tests where possible
  • Is this a breaking change? If yes, add "[BREAKING]" prefix to the title of the PR.

📱 Kick off Copilot coding agent tasks wherever you are with GitHub Mobile, available on iOS and Android.

… 1.40.0 conventions

Co-authored-by: TaoChenOSU <12570346+TaoChenOSU@users.noreply.github.com>
@github-actions github-actions bot changed the title [WIP] Add observability for MCP tool according to latest OTel 1.40.0 conventions Python: [WIP] Add observability for MCP tool according to latest OTel 1.40.0 conventions Mar 16, 2026
@markwallace-microsoft markwallace-microsoft added the documentation Improvements or additions to documentation label Mar 16, 2026
@TaoChenOSU
Copy link
Contributor

@copilot Address comments

@markwallace-microsoft
Copy link
Member

markwallace-microsoft commented Mar 16, 2026

Python Test Coverage

Python Test Coverage Report •
FileStmtsMissCoverMissing
packages/core/agent_framework
   _mcp.py5738684%103–104, 114–119, 130, 135, 178, 187, 199–200, 251, 260, 323, 331, 390, 504, 539, 552, 554–557, 576–577, 590–593, 595–596, 600, 657, 693, 695–696, 700–701, 703–704, 758, 773, 791, 836, 960, 973–978, 1002, 1071–1072, 1091–1094, 1102–1108, 1127, 1139, 1169–1172, 1177–1179, 1184–1186, 1203–1207, 1354
   observability.py6873195%380–381, 408, 410–412, 415–417, 422–423, 429–430, 436–437, 727, 731–733, 735, 1078–1082, 1298–1299, 1545, 1756, 2140, 2142
TOTAL24060265588% 

Python Unit Test Overview

Tests Skipped Failures Errors Time
5237 20 💤 0 ❌ 0 🔥 1m 23s ⏱️

…sion from mcp.types

Co-authored-by: TaoChenOSU <12570346+TaoChenOSU@users.noreply.github.com>
Copilot AI changed the title Python: [WIP] Add observability for MCP tool according to latest OTel 1.40.0 conventions Python: Add observability for MCP tool according to latest OTel 1.40.0 conventions Mar 16, 2026
Copilot AI requested a review from TaoChenOSU March 16, 2026 20:42
@TaoChenOSU TaoChenOSU marked this pull request as ready for review March 16, 2026 21:12
Copilot AI review requested due to automatic review settings March 16, 2026 21:12
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR adds OpenTelemetry (OTel) CLIENT-span instrumentation for MCP client operations in the Python core package, aligning MCP tool/prompt calls with the MCP semantic conventions (OTel 1.40.0), and updates/extends MCP GitHub PAT samples plus tests.

Changes:

  • Added MCP-specific OTel attributes and a helper to create MCP CLIENT spans.
  • Instrumented MCP call_tool() and get_prompt() with CLIENT spans and error/status handling, including JSON-RPC/MCP protocol attributes.
  • Expanded MCP tests and added new GitHub PAT samples + updated MCP samples README.

Reviewed changes

Copilot reviewed 5 out of 6 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
python/packages/core/agent_framework/observability.py Adds MCP-related OTel attributes and a get_mcp_call_span() helper to standardize CLIENT spans.
python/packages/core/agent_framework/_mcp.py Wraps MCP tool/prompt calls in CLIENT spans, injects trace context into MCP meta, and emits MCP/JSON-RPC attributes and error info.
python/packages/core/tests/core/test_mcp.py Updates OTel meta propagation expectations and adds span-attribute/error behavior tests for MCP calls.
python/samples/02-agents/mcp/mcp_github_pat_openai_responses.py New sample showing GitHub MCP usage via OpenAI Responses hosted MCP tool config.
python/samples/02-agents/mcp/mcp_github_pat_azure_chat.py New sample showing GitHub MCP usage via local MCPStreamableHTTPTool with Azure OpenAI chat client.
python/samples/02-agents/mcp/README.md Updates the sample list and environment variable prerequisites for the new GitHub PAT samples.

@TaoChenOSU
Copy link
Contributor

@copilot Address the comments

…ion, README prereqs, and AsyncClient lifecycle

Co-authored-by: TaoChenOSU <12570346+TaoChenOSU@users.noreply.github.com>
Copy link
Contributor Author

Copilot AI commented Mar 16, 2026

@copilot Address the comments

Addressed all three automated review comments in 1306e5b:

  1. _mcp.py:980except ToolExecutionException now sets error.type/ERROR status on the span. A _span_error_set flag prevents overwriting the existing "ToolError" attribute that was already set by the result.isError path.

  2. README.md:20-22 — Removed OPENAI_API_KEY / OPENAI_RESPONSES_MODEL_ID from the top-level prerequisites section (they only apply to the OpenAI Responses sample). Replaced with a note that each sample has its own prerequisites listed below.

  3. mcp_github_pat_azure_chat.py:49-65AsyncClient is now created with async with and combined into a single async with statement with MCPStreamableHTTPTool, ensuring it is always closed.

Copy link
Member

@eavanvalkenburg eavanvalkenburg left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Automated Code Review

Reviewers: 3 | Confidence: 89%

✗ Security Reliability

The diff adds OpenTelemetry span instrumentation to MCP call_tool and get_prompt methods, derives the JSON-RPC protocol version from type annotations at module load, captures a private SDK attribute (_request_id) for span metadata, and adds a new Azure OpenAI sample. The changes are generally sound from a security/reliability perspective. The main reliability concern is the module-level _JSONRPC_PROTOCOL_VERSION derivation which accesses internal mcp library type metadata and will crash with an unhandled IndexError/KeyError at import time if the library's type structure changes. There is also minor information leakage via str(ex) in span status messages, though this is low-risk as spans are typically internal observability data. The reliance on a private _request_id attribute is acknowledged in comments and handled safely with getattr defaults.

✓ Test Coverage

The diff adds OTel span instrumentation to call_tool and get_prompt in _mcp.py, a new get_mcp_call_span helper in observability.py, and new MCP-specific OtelAttr enum members. Test coverage for the happy paths and several error paths (McpError, tool error result, generic exception) is solid with meaningful attribute assertions. However, there are notable gaps: no tests verify span error attributes for the ClosedResourceError reconnection paths (both reconnect failure and second-attempt failure), no test verifies that connect() correctly sets _mcp_protocol_version, no unit test for get_mcp_call_span itself in test_observability.py, and no test for get_prompt with McpError (only RuntimeError is tested).

✗ Design Approach

The PR adds OTel CLIENT spans for MCP tool/prompt calls following the OTel semantic conventions, which is a reasonable approach. Two design-level issues stand out. First, _JSONRPC_PROTOCOL_VERSION is derived via reflection on the MCP library's Pydantic model field annotation (typing.get_args(...)[0]). This raises IndexError at module import time if the MCP library ever changes that field's type away from Literal["2.0"] — a plausible change as the library evolves. JSON-RPC 2.0 is a stable, versioned protocol specification; the right fix is to hardcode "2.0" as a named constant, not to derive it via fragile reflection. Second, the if self._mcp_protocol_version: truthiness checks would silently skip emitting the mcp.protocol.version span attribute when the negotiated version is the integer 0 (which is permitted by the str | int type of InitializeResult.protocolVersion). This should be is not None. The _request_id pre-call read is a minor concern: it reads a private SDK attribute to predict the next request ID before the call is dispatched, which is racy under concurrent session usage; this is flagged as a suggestion since there is no public API alternative today.

Flagged Issues

  • Module-level _JSONRPC_PROTOCOL_VERSION is derived via typing.get_args(types.JSONRPCRequest.model_fields["jsonrpc"].annotation)[0] at import time. If the MCP library changes this field's annotation away from Literal["2.0"] (e.g., to plain str), get_args returns () and [0] raises IndexError; if the field is renamed, model_fields['jsonrpc'] raises KeyError. Either crashes all code that imports _mcp. JSON-RPC 2.0 is a stable IETF specification constant—hardcode _JSONRPC_PROTOCOL_VERSION = "2.0" rather than deriving it via reflection, or at minimum wrap in try/except with a "2.0" fallback.
  • if self._mcp_protocol_version: uses truthiness rather than is not None in both call_tool (line 947) and get_prompt (line 1052). Since InitializeResult.protocolVersion is typed str | int, an integer version of 0 is falsy and would silently suppress the mcp.protocol.version span attribute. Both occurrences should use if self._mcp_protocol_version is not None:.

Suggestions

  • Consider truncating or sanitizing exception messages passed to span.set_status() (via str(ex)) to avoid leaking internal details (file paths, connection strings) into telemetry backends, especially for unexpected Exception types.
  • The f-string logger calls (e.g., logger.error(f"MCP connection closed...")) eagerly format the string. Prefer lazy formatting: logger.error("MCP connection closed unexpectedly after reconnection: %s", cl_ex). This is a pre-existing pattern, but new lines replicate it.
  • Add a test verifying that _mcp_protocol_version is set during connect() from init_result.protocolVersion. This new behavior is untested.
  • Add tests for ClosedResourceError paths in call_tool and get_prompt that verify span error attributes (e.g., reconnect failure sets error.type to the reconnect exception type, second-attempt failure sets it to ClosedResourceError). The existing reconnection tests don't use span_exporter.
  • Add a test for get_prompt when McpError is raised, verifying that error.type is set to the MCP error code (mirroring test_call_tool_span_sets_error_on_mcp_error).
  • Consider adding a direct unit test for get_mcp_call_span in test_observability.py to verify span name construction, SpanKind, and that set_status_on_exception=False / record_exception=False are passed correctly.
  • The _request_id pre-call capture (getattr(self.session, "_request_id", None)) reads a private SDK counter before the call is dispatched to predict the next request ID. Under concurrent session usage this is racy—another coroutine could consume the ID between the read and the send. Consider omitting jsonrpc.request.id from the span until the MCP SDK exposes a public API, rather than relying on an internal counter that could silently produce wrong IDs.

Automated review by eavanvalkenburg's agents

Comment on lines +66 to +67
# Derive the JSON-RPC protocol version used by the MCP library from its type annotations.
# mcp.types.JSONRPCRequest defines `jsonrpc: Literal["2.0"]`; extracting it here ensures
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

_JSONRPC_PROTOCOL_VERSION is derived via reflection on internal Pydantic model_fields metadata and typing.get_args(...)[0]. If the MCP library changes JSONRPCRequest's jsonrpc field annotation away from Literal["2.0"] (or renames the field), this raises IndexError or KeyError at import time. JSON-RPC 2.0 is a stable protocol constant—prefer hardcoding "2.0" directly, or at minimum add a try/except fallback.

Suggested change
# Derive the JSON-RPC protocol version used by the MCP library from its type annotations.
# mcp.types.JSONRPCRequest defines `jsonrpc: Literal["2.0"]`; extracting it here ensures
_JSONRPC_PROTOCOL_VERSION: str = "2.0"

OtelAttr.TOOL_NAME: tool_name,
OtelAttr.OPERATION: OtelAttr.TOOL_EXECUTION_OPERATION,
OtelAttr.JSONRPC_PROTOCOL_VERSION: _JSONRPC_PROTOCOL_VERSION,
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Truthiness check will silently skip emitting mcp.protocol.version if the server negotiates integer version 0 (allowed by str | int). Use is not None.

Suggested change
}
if self._mcp_protocol_version is not None:

OtelAttr.JSONRPC_PROTOCOL_VERSION: _JSONRPC_PROTOCOL_VERSION,
}
if self._mcp_protocol_version:
span_attributes[OtelAttr.MCP_PROTOCOL_VERSION] = self._mcp_protocol_version
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same truthiness issue as in call_tool: use is not None to correctly handle a negotiated protocol version of 0.

Suggested change
span_attributes[OtelAttr.MCP_PROTOCOL_VERSION] = self._mcp_protocol_version
if self._mcp_protocol_version is not None:



@pytest.fixture
def mcp_test_server_class():
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The mcp_test_server_class fixture's connect() never sets _mcp_protocol_version. Consider adding a test that exercises the real connect() path (or at least verifies the assignment) to cover the new self._mcp_protocol_version = init_result.protocolVersion line.

"""get_prompt emits a CLIENT span following OTel MCP conventions."""
from opentelemetry.trace import SpanKind

server = mcp_test_server_class()
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test verifies mcp.protocol.version is present when explicitly set, but there's no assertion that it's absent in the default case (e.g., in test_call_tool_creates_mcp_client_span). Adding assert 'mcp.protocol.version' not in span.attributes in the default-path test would confirm the conditional logic works both ways.

assert span.attributes["error.type"] == "RuntimeError"


# endregion
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Only RuntimeError is tested for get_prompt error span handling. Consider adding a parallel test for McpError (similar to test_call_tool_span_sets_error_on_mcp_error) to verify error.type is set to the MCP error code string rather than a class name.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation python

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants